bookwiz.io / app / dashboard / chat / [chatId] / page.tsx
page.tsx
Raw
'use client'

import { useState, useEffect, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useAuth } from '@/components/AuthProvider'
import { MessageUI } from '@/lib/types/database'
import { DEFAULT_MODEL } from '@/lib/config/models'
import ChatMessages from '@/components/chat/ChatMessages'
import ModelSelector from '@/components/ModelSelector'
import { IoSendOutline, IoStopOutline, IoCopyOutline } from 'react-icons/io5'
import { useChatContext } from '@/lib/contexts/ChatContext'
import { useUsageLimit } from '@/lib/hooks/useUsageLimit'
import { deleteMessage } from '@/lib/utils/chatMessageUtils'

export default function ChatPage() {
  const { chatId } = useParams()
  const router = useRouter()
  const { currentChatId, setCurrentChatId, updateChatTimestamp } = useChatContext()
  const { user } = useAuth()
  const [messages, setMessages] = useState<MessageUI[]>([])
  const [inputValue, setInputValue] = useState('')
  const [isStreaming, setIsStreaming] = useState(false)
  const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL.name)
  const [isLoading, setIsLoading] = useState(false)
  const [currentChat, setCurrentChat] = useState<any>(null)
  
  const abortControllerRef = useRef<AbortController | null>(null)
  const loadMessagesAbortControllerRef = useRef<AbortController | null>(null)
  const { usageInfo } = useUsageLimit(user?.id || null)

  // Sync URL chatId with context
  useEffect(() => {
    if (chatId && typeof chatId === 'string') {
      setCurrentChatId(chatId)
    }
  }, [chatId, setCurrentChatId])

  // Load messages for current chat
  useEffect(() => {
    // Cancel any ongoing message loading
    if (loadMessagesAbortControllerRef.current) {
      loadMessagesAbortControllerRef.current.abort()
    }

    if (currentChatId && user?.id) {
      // Immediately clear messages to prevent showing old chat messages
      setMessages([])
      setCurrentChat(null)
      setIsLoading(true)
      loadChatMessages(currentChatId)
      setInputValue('')
    } else {
      setMessages([])
      setCurrentChat(null)
      setInputValue('')
      setIsLoading(false)
    }
  }, [currentChatId, user?.id])

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (loadMessagesAbortControllerRef.current) {
        loadMessagesAbortControllerRef.current.abort()
      }
      if (abortControllerRef.current) {
        abortControllerRef.current.abort()
      }
    }
  }, [])

  const loadChatMessages = async (chatId: string) => {
    try {
      setIsLoading(true)
      
      // Create new abort controller for this request
      loadMessagesAbortControllerRef.current = new AbortController()
      const signal = loadMessagesAbortControllerRef.current.signal

      const { supabase } = await import('@/lib/supabase')

      // Load both chat details and messages
      const [chatResult, messagesResult] = await Promise.all([
        supabase
          .from('chats')
          .select('*')
          .eq('id', chatId)
          .abortSignal(signal)
          .single(),
        supabase
          .from('messages')
          .select('*')
          .eq('chat_id', chatId)
          .order('sequence_number', { ascending: true })
          .abortSignal(signal)
      ])

      // Check if this request was cancelled or if we switched to a different chat
      if (signal.aborted || currentChatId !== chatId) {
        return
      }

      if (chatResult.error) {
        console.error('Error loading chat:', chatResult.error)
        // Only redirect if the chat doesn't exist (not found)
        if (chatResult.error.code === 'PGRST116') {
          setTimeout(() => router.push('/dashboard/chat'), 1000)
        }
        return
      }

      if (messagesResult.error) {
        console.error('Error loading messages:', messagesResult.error)
        return
      }

      // Double-check we're still on the same chat before setting state
      if (currentChatId !== chatId) {
        return
      }

      // Set chat details
      setCurrentChat(chatResult.data)

      // Set model from chat if available
      if (chatResult.data.model) {
        setSelectedModel(chatResult.data.model)
      }

      const formattedMessages: MessageUI[] = messagesResult.data.map((msg: any) => ({
        id: msg.id,
        type: msg.type,
        content: msg.content,
        timestamp: new Date(msg.created_at),
        model: msg.model,
        tool_results: msg.tool_results,
        context_info: msg.context_info
      }))

      // Final check before setting messages
      if (currentChatId === chatId && !signal.aborted) {
        setMessages(formattedMessages)
      }
    } catch (error: any) {
      // Don't log errors for aborted requests
      if (error.name === 'AbortError') {
        return
      }
      
      console.error('Error loading chat messages:', error)
      // Only redirect on genuine errors, not during normal loading
      if (currentChatId === chatId) {
        setTimeout(() => router.push('/dashboard/chat'), 1000)
      }
    } finally {
      // Only update loading state if we're still on the same chat
      if (currentChatId === chatId) {
        setIsLoading(false)
      }
    }
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    
    if (!inputValue.trim() || !user?.id || isStreaming) return

    const userMessage = inputValue.trim()
    setInputValue('')
    setIsStreaming(true)

    // Add user message to UI immediately
    const tempUserMessage: MessageUI = {
      id: `temp-${Date.now()}`,
      type: 'user',
      content: userMessage,
      timestamp: new Date()
    }

    const currentMessages = currentChatId ? [...messages, tempUserMessage] : [tempUserMessage]
    setMessages(currentMessages)

    try {
      abortControllerRef.current = new AbortController()

      const response = await fetch('/api/standalone-chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          messages: currentMessages,
          model: selectedModel,
          userId: user?.id,
          chatId: currentChatId
        }),
        signal: abortControllerRef.current.signal
      })

      if (!response.ok) {
        const errorData = await response.json()
        throw new Error(errorData.error || 'Failed to send message')
      }

      // Handle streaming response
      const reader = response.body?.getReader()
      if (!reader) throw new Error('No response body')

      const decoder = new TextDecoder()
      let aiResponse = ''

      const tempAiMessage: MessageUI & { _isStreaming?: boolean } = {
        id: `temp-ai-${Date.now()}`,
        type: 'ai',
        content: '',
        timestamp: new Date(),
        model: selectedModel,
        _isStreaming: true
      }

      setMessages(prev => [...prev, tempAiMessage])

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const chunk = decoder.decode(value, { stream: true })
        const lines = chunk.split('\n')

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            try {
              const data = JSON.parse(line.slice(6))
              
              if (data.type === 'content') {
                aiResponse += data.content
                setMessages(prev => prev.map(msg => 
                  msg.id === tempAiMessage.id 
                    ? { ...msg, content: aiResponse }
                    : msg
                ))
              } else if (data.type === 'done') {
                // Update the timestamp for existing chat without full refresh
                if (currentChatId && updateChatTimestamp) {
                  updateChatTimestamp(currentChatId)
                }
              } else if (data.type === 'error') {
                throw new Error(data.error)
              }
            } catch (e) {
              // Skip invalid JSON
            }
          }
        }
      }

      // Remove streaming flag
      setMessages(prev => prev.map(msg => 
        msg.id === tempAiMessage.id 
          ? { ...msg, _isStreaming: false }
          : msg
      ))

    } catch (error: any) {
      console.error('Error sending message:', error)
      
      if (error.name === 'AbortError') {
        // Request was cancelled
        setMessages(prev => prev.slice(0, -2)) // Remove both user and AI messages
      } else {
        // Show error message
        const errorMessage: MessageUI = {
          id: `error-${Date.now()}`,
          type: 'ai',
          content: `Sorry, I encountered an error: ${error.message}`,
          timestamp: new Date()
        }
        setMessages(prev => [...prev.slice(0, -1), errorMessage]) // Replace AI message with error
      }
    } finally {
      setIsStreaming(false)
      abortControllerRef.current = null
    }
  }

  const stopExecution = () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort()
    }
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSubmit(e as any)
    }
  }

  const getAvatarUrl = () => user?.user_metadata?.avatar_url || null
  const getInitials = () => {
    const fullName = user?.user_metadata?.full_name
    if (fullName) {
      return fullName.split(' ').map((n: string) => n[0]).join('').toUpperCase()
    }
    return user?.email?.[0]?.toUpperCase() || 'U'
  }

  const getPlaceholder = () => {
    if (isStreaming) return "AI is thinking..."
    return "Message the AI..."
  }

  const copyToClipboard = (text: string) => {
    navigator.clipboard.writeText(text)
  }

  const handleDeleteMessage = async (messageId: string) => {
    try {
      await deleteMessage(messageId)
      
      // Remove the message from the local state
      setMessages(prev => prev.filter(msg => msg.id.toString() !== messageId))
      
      // Update chat metadata if needed
      if (currentChatId && updateChatTimestamp) {
        updateChatTimestamp(currentChatId)
      }
    } catch (error: any) {
      console.error('Error deleting message:', error)
      // You could show a toast notification here
      throw error // Re-throw to let the Message component handle the error state
    }
  }

  if (isLoading) {
    return (
      <div className="flex flex-col h-full">
        <div className="border-b border-white/10 bg-black/40 backdrop-blur-sm px-4 py-2 md:pl-4 pl-4">
          <h1 className="text-sm font-medium text-slate-300">Loading...</h1>
        </div>
        <div className="flex-1 flex items-center justify-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
        </div>
      </div>
    )
  }

  return (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="border-b border-white/10 bg-black/40 backdrop-blur-sm px-4 py-2 md:pl-4 pl-4">
        <div className="flex items-center justify-between">
          <h1 className="text-sm font-medium text-slate-300 truncate flex-1">
            {currentChat ? currentChat.title : 'Chat'}
          </h1>
          {currentChatId && (
            <div className="flex items-center gap-2 text-xs text-slate-400">
              <span className="font-mono">ID: {currentChatId.slice(0, 8)}...</span>
              <button
                onClick={() => copyToClipboard(currentChatId)}
                className="p-1 hover:bg-white/10 rounded text-slate-400 hover:text-slate-300 transition-colors"
                title="Copy chat ID"
              >
                <IoCopyOutline className="w-3 h-3" />
              </button>
            </div>
          )}
        </div>
      </div>

      {/* Messages Area */}
      <div className="flex-1 flex flex-col min-h-0">
        <div className="flex-1 overflow-y-auto">
          <div className="max-w-4xl mx-auto px-4">
            <ChatMessages
              allDisplayMessages={messages}
              isStreaming={isStreaming}
              currentChat={currentChat}
              contextInfo={null}
              toolResults={[]}
              getAvatarUrl={getAvatarUrl}
              getInitials={getInitials}
              onFileClick={async () => {}} // No file operations in standalone chat
              onDeleteMessage={handleDeleteMessage}
            />
          </div>
        </div>
      </div>

      {/* Input Area */}
      <div className="bg-black/40 backdrop-blur-sm px-4 py-3">
        <div className="max-w-4xl mx-auto">
          <form onSubmit={handleSubmit}>
            <div className="relative bg-white/5 backdrop-blur-sm border border-white/10 rounded-lg transition-all">
              <textarea
                value={inputValue}
                onChange={(e) => {
                  setInputValue(e.target.value)
                  e.target.style.height = 'auto'
                  e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
                }}
                onKeyDown={handleKeyDown}
                placeholder={getPlaceholder()}
                className="w-full bg-transparent text-slate-200 placeholder-slate-400 border-0 outline-none resize-none px-4 py-3 pr-12 pb-12 min-h-[3rem] max-h-[7.5rem]"
                disabled={isStreaming}
              />
              
              <div className="absolute left-4 bottom-3 flex items-center gap-2">
                <ModelSelector
                  selectedModel={selectedModel}
                  onModelChange={setSelectedModel}
                  disabled={isStreaming}
                  variant="minimal"
                  className="min-w-0"
                  usageInfo={usageInfo}
                />
              </div>
              
              <div className="absolute right-2 bottom-3 flex items-center gap-2">
                {isStreaming ? (
                  <button
                    type="button"
                    onClick={stopExecution}
                    className="p-2 text-slate-400 hover:text-slate-200 hover:bg-white/10 rounded-md transition-colors"
                    title="Stop generation"
                  >
                    <IoStopOutline className="h-4 w-4" />
                  </button>
                ) : (
                  <button
                    type="submit"
                    disabled={!inputValue.trim() || isStreaming}
                    className="p-2 text-slate-400 hover:text-slate-200 hover:bg-white/10 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                    title="Send message"
                  >
                    <IoSendOutline className="h-4 w-4" />
                  </button>
                )}
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>
  )
}